02 - 两套语义约定
前置:01 篇的操作类型与指标。
本篇回答:同一件事为什么存在两套规范、各自覆盖什么、以及怎么让这个选择在未来可以反悔。
本篇会用到的词:
| 词 | 意思 |
|---|---|
| 命名空间(前缀) | 属性名开头那一段,比如 gen_ai.usage.input_tokens 里的 gen_ai。两套规范争的就是这一段该叫什么 |
| OTLP | OpenTelemetry Protocol,OpenTelemetry 定义的数据传输协议。两套语义约定都走它,所以传输层不是它们的分歧所在 |
| vendor-neutral | 厂商中立 —— 数据 不绑定在某一家产品上,换后端时能带走。这通常是合规要求,不是技术偏好 |
| 仪表化库(instrumentation) | 替你自动产生 span 的现成库,装上就能给 OpenAI SDK、LangChain 这些常用组件自动埋点,不用手写 |
| Stable / Development | 规范里给每个字段标的稳定性等级。Development 意味着随时可能改名或删掉,本篇的核心结论就跟这个标记有关 |
| 翻译层 | 后端把外来格式映射进自己数据模型的那一层。它让一个后端能同时吃两套约定,但吃进去之后落到的字段不一定相同 |
一、两套规范的定位
| OpenTelemetry GenAI | OpenInference | |
|---|---|---|
| 仓库 | open-telemetry/semantic-conventions-genai | Arize-ai/openinference |
| 协议 | Apache-2.0 | Apache-2.0 |
| 属性前缀 | gen_ai.* | llm.* document.* embedding.* |
| 出身 | OpenTelemetry 官方 | Arize(Phoenix 的开发方) |
| 定位 | 通用可观测标准的 GenAI 扩展 | 面向 LLM 应用调试的实用约定 |
关于用 star 数比较这两个项目
规范类仓库的 star 数没有比较意义 —— 使用者 star 的是 SDK 和平台,不是规范文本。判断采纳度应看哪些平台和网关实现了它,见第三节。
二、覆盖范围不同
两套的差异不在命名风格,而在建模粒度:
OTel GenAI 在 Agent 编排侧更完整(有 invoke_agent、plan、invoke_workflow)。
OpenInference 在 RAG 侧更细:document.* 可以逐条记录召回的文档 id、相关性分数和内容片段 —— 调试"为什么召回了不相关的东西"时,这个粒度是必需的。
三、生态站队
| 平台 | 采用的约定 | 说明 |
|---|---|---|
| Arize Phoenix(★11,105) | OpenInference | 自家的 llm.* 命名空间,靠翻译层兼容其他 |
| Langfuse(★33,369) | 自有数据模型 | 接受 OTLP,把 gen_ai.* 和 OpenInference 都映射进自己的模型 |
| OpenLLMetry(★7,384) | OTel 风格 | Apache-2.0,仪表化库 |
| Envoy AI Gateway | OpenInference | 见下 |
3.1 Envoy AI Gateway 的选择
Envoy AI Gateway 的源码里有一个完整的 internal/tracing/openinference/ 目录:
internal/tracing/openinference/openai/request_attrs.go 38 KB
internal/tracing/openinference/openai/response_attrs.go 21 KB
一个 CNCF 生态的项目,在追踪层选了非 OTel 官方的约定。 这不是偶然 —— 对网关来说,能否把请求和响应的细节结构化地记下 来,比命名是否"官方"更重要,而 OpenInference 在这方面更成熟。
这是判断采纳度比 star 数可靠得多的信号:看基础设施项目在生产路径上用了谁。
四、真正的建议:不要在业务代码里选边
两套都在演进,OTel GenAI 甚至尚未稳定(01 篇 5 节)。在业务代码里直接写属性名,等于把规范的不确定性扩散到每一个调用点。
# ❌ 业务代码直接依赖某一套约定
# 换约定 = 全局搜索替换,且两套并存时无法处理
span.set_attribute("gen_ai.usage.input_tokens", n)
span.set_attribute("gen_ai.request.model", model)
# ✅ 收敛到一个转换层,由它决定输出哪一套(或同时输出两套)
class SpanAttrs:
"""埋点属性的唯一出口。
业务代码只调用语义化方法,不接触具体 属性名。
切换约定、同时输出两套、适配规范变更,都只改这个类。
"""
def __init__(self, span, dialect: str = "otel"):
self._span = span
self._dialect = dialect # "otel" | "openinference" | "both"
def input_tokens(self, n: int) -> None:
if self._dialect in ("otel", "both"):
self._span.set_attribute("gen_ai.usage.input_tokens", n)
if self._dialect in ("openinference", "both"):
self._span.set_attribute("llm.token_count.prompt", n)
def model(self, name: str) -> None:
if self._dialect in ("otel", "both"):
self._span.set_attribute("gen_ai.request.model", name)
if self._dialect in ("openinference", "both"):
self._span.set_attribute("llm.model_name", name)
同时输出两套的额外成本很低(每个 span 多几个属性),换来的是后端可以随时更换。在规范未稳定期间,这个交换是划算的。
五、两个常见误解
5.1 以为选了后端就等于选了约定
后端和约定是两个独立的选择: